在上一篇我們完成了登入頁的組件化設計,讓程式碼更易於維護。既然基礎結構已經打好,接下來就能專心加入更進階的功能了🥳。
這一篇,我們要把表單驗證的部分徹底完成,延續 房門與門鎖[ 2 / 6 ]:React 生態系導入 — 表單驗證 & Router 分頁 的簡易表單驗證,加入 reCAPTCHA 驗證 與 zxcvbn.js 密碼強度檢測。
本篇重點整理:
「我不是機器人🤖」這個詞是不是很熟悉呢?
這是 Google 開發的防機器人驗證功能,目前最常見的就是 reCAPTCHA v2 ( 核取方框 ),以及 v3 ( 隱形驗證 )。
它主要解決了什麼問題?
首先進入 Google reCAPTCHA 網站,登入後申請建立新網站:
localhost
申請完成後會得到 Site Key (公開金鑰) 與 Secret Key (秘密金鑰),兩者用途不同:
如果把秘密金鑰放到前端或公開到 GitHub,會有以下風險:
常見做法:
.env
檔案,例如:RECAPTCHA_SECRET_KEY=your-key
並在 .gitignore
中忽略 .env
,避免 push 到 GitHub。
🔰設定好
.env
後,別忘了要在vite-env.d.ts
中設定型別哦!
就算通過 reCAPTCHA 機器人驗證,如果使用者輸入一個「123456」當密碼,那系統依然非常危險⚠️。
因此登入/註冊頁還需要密碼強度驗證,在使用者輸入時立即提示密碼的安全等級。
常見的規則檢測包含:
!@#$%^&*
等這就是為什麼在之前的簡易驗證裡,會有像這樣的 Regex:
// validSchema.ts 部分程式碼
const passwordRegex = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]+$/;
其實並不會。
允許使用特殊字元的目的是增加密碼組合的複雜度,讓暴力破解的可能性更低。
真正需要注意的反而是:
所以開放特殊字元是安全且推薦的做法,只要後端正確處理就沒問題。
基於上面的規則,我們當然可以自己寫 Regex 或條件判斷來做密碼驗證,但這種方法只能檢查 格式是否符合要求,卻無法判斷使用者輸入的密碼是不是常見弱密碼 ( 例如 password123 或 qwerty )。
這時候,我更推薦使用 Dropbox 開發的 zxcvbn。
這個工具除了檢查密碼格式,還會:
❗小缺點:改進建議目前只支援英文,對中文使用者來說可能不夠直觀,但整體功能仍然非常強大。
import zxcvbn from "zxcvbn";
const result = zxcvbn("MyP@ssw0rd!2025");
console.log(result.score); // 0~4,越高越安全
console.log(result.feedback.suggestions); // 改進建議
✅ 這樣一來,密碼強度檢測不僅僅是「格式驗證」,還能讓使用者更清楚了解自己的密碼到底安不安全。
部分程式碼RecaptchaField.tsx
import ReCAPTCHA from "react-google-recaptcha";
interface RecaptchaFieldProps {
onChange: (value: string | null) => void;
error?: string;
}
export default function RecaptchaField({
onChange,
error,
}: RecaptchaFieldProps) {
// 從環境變數中讀取 reCAPTCHA 網站金鑰
const recaptchaSiteKey = import.meta.env.VITE_RECAPTCHA_PUBLIC_SITE_KEY;
return (
<div className="flex flex-col items-center">
<ReCAPTCHA sitekey={recaptchaSiteKey} onChange={onChange} />
{error && <p className="mt-1 text-red-600 text-xs">{error}</p>}
</div>
);
}
passwordStrengh.ts
import zxcvbn from "zxcvbn";
export type PasswordStrength =
| "None"
| "Weak"
| "Moderate"
| "Strong"
| "VeryStrong";
interface PasswordStrengthResult {
score: number;
strength: PasswordStrength;
barColor: string;
textColor: string;
text: string;
}
export const zxcvbnPS = (password: string): PasswordStrengthResult => {
if (!password || password.length === 0) {
return {
score: -1, // 設定初始值小於 0
strength: "None",
barColor: "bg-gray-300 w-0",
textColor: "text-gray-500",
text: "",
};
}
const result = zxcvbn(password);
const score = result.score; // zxcvbn 分數範圍從 0 (worst) 到 4 (best)
let strength: PasswordStrength;
let barColor: string;
let textColor: string;
let text: string;
switch (score) {
case 0:
strength = "Weak";
barColor = "bg-red-500 w-1/5";
textColor = "text-red-500";
text = "非常弱";
break;
case 1:
strength = "Weak";
barColor = "bg-orange-500 w-2/5";
textColor = "text-orange-500";
text = "弱";
break;
case 2:
strength = "Moderate";
barColor = "bg-yellow-500 w-3/5";
textColor = "text-yellow-500";
text = "中等";
break;
case 3:
strength = "Strong";
barColor = "bg-lime-500 w-4/5";
textColor = "text-lime-500";
text = "強";
break;
case 4:
strength = "VeryStrong";
barColor = "bg-green-500 w-full";
textColor = "text-green-500";
text = "非常強";
break;
default:
strength = "None";
barColor = "bg-gray-300 w-0";
textColor = "text-gray-500";
text = "";
break;
}
return {
score,
strength,
barColor,
textColor,
text,
};
};
RegisterForm.tsx
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { registerSchema, type RegisterInputs } from "../../utils/validSchema";
import InputField from "@components/ui/InputField";
import RecaptchaField from "@/components/ui/RecaptchaField";
export default function RegisterForm() {
const {
register,
handleSubmit,
setValue,
formState: { errors },
watch,
trigger,
} = useForm<RegisterInputs>({
resolver: yupResolver(registerSchema),
mode: "onBlur",
});
const passwordValue = watch("password");
const onRecaptchaChange = (value: string | null) => {
setValue("recaptcha", value ?? "");
trigger("recaptcha");
};
const onSubmit = (data: RegisterInputs) => {
console.log("表單提交資料:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col space-y-8">
{/* 使用者名稱 */}
<InputField
id="username"
label="使用者名稱"
icon="user"
type="text"
autoComplete="username"
register={register}
errors={errors}
/>
{/* 電子郵件 */}
<InputField
id="email"
label="電子郵件"
icon="envelope"
type="email"
autoComplete="email"
register={register}
errors={errors}
/>
{/* 密碼 */}
<InputField
id="password"
label="密碼"
icon="lock"
type="password"
autoComplete="new-password"
register={register}
errors={errors}
showStrength={true}
passwordValue={passwordValue}
/>
{/* 確認密碼 */}
<InputField
id="confirmPassword"
label="確認密碼"
icon="check"
type="password"
autoComplete="new-password"
register={register}
errors={errors}
showPwdBtn={true}
/>
{/* reCAPTCHA 核取方塊 */}
<RecaptchaField
onChange={onRecaptchaChange}
error={errors.recaptcha?.message}
/>
{/* 已接受服務條款 核取方塊 */}
<div className="relative flex items-center space-x-2">
<input
type="checkbox"
id="termsAccepted"
{...register("termsAccepted")}
/>
<label htmlFor="termsAccepted" className="text-gray-700">
我已閱讀並同意{" "}
<a href="/terms" className="text-primary-dark hover:underline">
服務條款
</a>
</label>
{errors.termsAccepted && (
<p className="absolute mt-10 ml-2 text-red-500 text-xs">
{errors.termsAccepted.message}
</p>
)}
</div>
{/* 註冊按鈕 */}
<div className="flex justify-center">
<button
type="submit"
className="w-3xs py-3 rounded-xl bg-primary-dark text-white text-xl font-bold hover:bg-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 shadow-md transition duration-200"
>
註冊
</button>
</div>
</form>
);
}